/*******************************************************************************
 * Copyright (c) 2002-2003 Boris Pruessmann and others. Copyright (C) 2007
 * Google Inc. (Mustafa K. Isik) All rights reserved. This program and the
 * accompanying materials are made available under the terms of the Common
 * Public License v0.5 which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/cpl-v05.html
 *
 * Contributors: Boris Pruessmann - Initial implementation. Mustafa K. Isik,
 * Google Inc. - codesurgeon@gmail.com
 ******************************************************************************/
package net.sourceforge.perforce.core.internal;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Externalizable;
import java.io.File;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectInputStream;
import java.io.ObjectOutput;
import java.io.ObjectOutputStream;
import java.io.StreamCorruptedException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import jdbm.btree.BTree;
import jdbm.helper.MRU;
import jdbm.helper.ObjectCache;
import jdbm.helper.StringComparator;
import jdbm.recman.RecordManager;

import net.sourceforge.perforce.core.PerforceCorePlugin;
import net.sourceforge.perforce.core.api.IPerforceServer;
import net.sourceforge.perforce.core.syncinfo.FolderSyncInfo;
import net.sourceforge.perforce.core.syncinfo.ResourceSyncInfo;

import org.eclipse.core.filesystem.EFS;
import org.eclipse.core.resources.IContainer;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.Status;

/**
 * @version $Revision: 489 $
 * @author <a href="mailto:bpruessmann@users.sourceforge.net">Boris
 *         Pruessmann</a>
 */
public class CacheStore {
  private static final boolean TRACE =
      getDebugOptionAsBoolean(PerforceCorePlugin.ID + "/cachestore/trace");

  private static boolean getDebugOptionAsBoolean(String option) {
    return "true".equalsIgnoreCase(Platform.getDebugOption(option));
  }

  /** The CacheStore current version. */
  private static final Integer CURRENT_VERSION = new Integer(20031118);

  /** Stores the server unique name. */
  private final String serverName;
  /** Stores the path of the cache store. */
  private IPath path;
  /** Stores the Record Manager. */
  private RecordManager recordManager;
  /** Stores the Record Manager's cache. */
  private ObjectCache recordManagerCache;
  /** Stores the cache for ResourceSyncInfos. */
  private PersistentMap resourceSyncCache;
  /** Stores the cache for FolderSyncInfos. */
  private PersistentMap folderSyncCache;
  /** Stores the FolderSyncInfos for projects. */
  private PersistentMap projectFolderSyncCache;
  /** The status of the CacheStore. */
  private IStatus status = new Status(IStatus.OK, PerforceCorePlugin.ID, 0, "", null);
  /** Indicates whether we should output trace info. */
  private final boolean debug;

  private static void trace(String fmt, Object... params) {
    if (TRACE) {
      System.out.print("CacheStore ");
      System.out.printf(fmt, params);
      System.out.println();
    }
  }

  /**
   * Constructor for CacheStore.
   *
   * @param server the perforce server.
   */
  public CacheStore(IPerforceServer server) {
    trace("start CacheStore constructor with IPerforceServer %s \n executed by thread %s", String
        .valueOf(server), Thread.currentThread().getName());
    try {

      serverName = server.getUniqueName();
      final String value = Platform.getDebugOption(PerforceCorePlugin.ID + "/cache");//$NON-NLS-1$
      debug = PerforceCorePlugin.getPlugin().isDebugging() && Boolean.valueOf(value).booleanValue();
      try {
        String filename = server.getPort() + '_' + server.getClient() + '_' + server.getUser();
        filename = filename.replace(':', '_');

        path = PerforceCorePlugin.getPlugin().getStateLocation().append(filename);
        if (debug) {
          System.out.println();
          System.out.println("CacheStore for " + serverName); //$NON-NLS-1$
          System.out.println("\t" + path.toOSString()); //$NON-NLS-1$
        }

        recordManager = new RecordManager(path.toOSString());
        recordManagerCache = new ObjectCache(recordManager, new MRU(5));

        boolean isValidVersion = true;
        long recordId = recordManager.getNamedObject("info");
        if (recordId == 0) {
          recordId = recordManager.insert(CURRENT_VERSION);
          recordManager.setNamedObject("info", recordId);
          recordManager.commit();
        } else {
          try {
            Object object = recordManager.fetchObject(recordId);
            if (!object.equals(CURRENT_VERSION)) {
              isValidVersion = false;
            }
          } catch (ClassNotFoundException e) {
            isValidVersion = false;
          }
        }

        if (!isValidVersion) {
          recordManager.close();
          recordManagerCache.dispose();

          status =
              new Status(IStatus.INFO, PerforceCorePlugin.ID, 0, "The cache store " + filename
                  + " was deleted due to a version conflict.", null);


          File cacheFile = new File(path.toOSString() + ".db");
          cacheFile.delete();

          cacheFile = new File(path.toOSString() + ".lg");
          cacheFile.delete();

          recordManager = new RecordManager(path.toOSString());
          recordManagerCache = new ObjectCache(recordManager, new MRU(5));

          recordId = recordManager.insert(CURRENT_VERSION);
          recordManager.setNamedObject("info", recordId);
          recordManager.commit();
        }

        resourceSyncCache = getPersistentMap("resourceSyncCache");
        folderSyncCache = getPersistentMap("folderSyncCache");
        projectFolderSyncCache = getPersistentMap("projectFolderSyncCache");
      } catch (IOException e) {
        status =
            new Status(IStatus.ERROR, PerforceCorePlugin.ID, 0,
                "An I/O error occured opening the cache store.", e);

        PerforceCorePlugin.logError(e.getMessage(), e);
      }
    } finally {
      trace("end CacheStore constructor");
    }
  }

  //----------------------------------------------------------------------------
  // -- Public Methods

  /**
   * Returns the status of the CacheStore.
   *
   * @return the CacheStore's status.
   */
  public IStatus getStatus() {
    return status;
  }

  /**
   * Call this method to check whether the cache has any pending changes.
   *
   * @return true if there are changes, false otherwise.
   */
  public boolean isDirty() {
    return projectFolderSyncCache.isDirty() || folderSyncCache.isDirty()
        || resourceSyncCache.isDirty();
  }

  /**
   * Called to commit the CacheStore's dirty data.
   *
   * @throws IOException in case of an I/O error.
   */
  public void commit() throws IOException {
    if (!isDirty()) {
      return;
    }

    if (debug) {
      System.out.println();
      System.out.print("Started committing CacheStore for " + serverName); //$NON-NLS-1$
      System.out.println(" " + new Date()); //$NON-NLS-1$
    }

    projectFolderSyncCache.commit();
    folderSyncCache.commit();
    resourceSyncCache.commit();

    if (debug) {
      System.out.print("Finished committing CacheStore for " + serverName); //$NON-NLS-1$
      System.out.println(" " + new Date()); //$NON-NLS-1$
    }
  }

  /**
   * Call this method to close the CacheStore.
   *
   * @throws IOException in case of an I/O error
   */
  public void close() throws IOException {
    commit();

    projectFolderSyncCache.dispose();
    folderSyncCache.dispose();
    resourceSyncCache.dispose();

    recordManager.close();
    recordManagerCache.dispose();
  }

  /** Call this method to remove the CacheStore from the system. */
  public void delete() {
    File dbFile = new File(path.toOSString() + ".db"); //$NON-NLS-1$
    dbFile.delete();

    File lgFile = new File(path.toOSString() + ".lg"); //$NON-NLS-1$
    lgFile.delete();
  }

  /**
   * Gets the project's FolderSyncInfo.
   *
   * @param project the project.
   * @return the FolderSyncInfo.
   */
  public FolderSyncInfo getProjectSyncInfo(IProject project) {
    try {
      String key = createCacheKeyFromPath(project.getLocation());
      return (FolderSyncInfo) projectFolderSyncCache.get(key);
    } catch (IOException e) {
      PerforceCorePlugin.logError(e.getMessage(), e);
    }

    return null;
  }

  /**
   * Gets FolderSyncInfo from cache.
   *
   * @param container the container for which we need the FolderSyncInfo.
   * @return the folder sync info or null.
   */
  @SuppressWarnings("unchecked")
  // folderSyncCache.get
  public FolderSyncInfo[] getFolderSyncInfo(IContainer container) {
    try {
      String key = createCacheKeyFromPath(container.getLocation());
      Map<Object, FolderSyncInfo> children = (Map<Object, FolderSyncInfo>) folderSyncCache.get(key);
      if (children != null) {
        return children.values().toArray(new FolderSyncInfo[children.size()]);
      }
    } catch (IOException e) {
      PerforceCorePlugin.logError(e.getMessage(), e);
    }

    return new FolderSyncInfo[0];
  }

  /**
   * Persistently stores the specified FolderSyncInfo for the given project.
   *
   * @param project the project.
   * @param syncInfo the FolderSyncInfo to store.
   * @return the previously stored FolderSyncInfo.
   */
  public FolderSyncInfo setProjectSyncInfo(IProject project, FolderSyncInfo syncInfo) {
    try {
      FolderSyncInfo result = null;
      String key = createCacheKeyFromPath(project.getLocation());
      result = (FolderSyncInfo) projectFolderSyncCache.get(key);
      if (syncInfo == null) {
        projectFolderSyncCache.remove(key);
      } else {
        projectFolderSyncCache.put(key, syncInfo);
      }

      return result;
    } catch (IOException e) {
      PerforceCorePlugin.logError(e.getMessage(), e);
      return null;
    }
  }

  /**
   * Persistently stores the specified FolderSyncInfo for the given container.
   *
   * @param container the container.
   * @param syncInfo the FolderSyncInfo to store.
   * @return the previously stored FolderSyncInfo.
   */
  @SuppressWarnings("unchecked")
  // folderSyncCache
  public FolderSyncInfo setFolderSyncInfo(IContainer container, FolderSyncInfo syncInfo) {
    FolderSyncInfo result = null;
    try {
      IContainer parent = container.getParent();
      String key = createCacheKeyFromPath(parent.getLocation());
      Map<String, FolderSyncInfo> children = (Map<String, FolderSyncInfo>) folderSyncCache.get(key);
      if (children != null) {
        result = children.get(container.getName());
        if (syncInfo == null) {
          children.remove(container.getName());
          if (children.size() == 0) {
            folderSyncCache.remove(key);
          } else {
            folderSyncCache.put(key, children);
          }
        } else {
          if (!syncInfo.equals(result)) {
            children.put(container.getName(), syncInfo);
            folderSyncCache.put(key, children);
          }
        }
      } else {
        children = new HashMap<String, FolderSyncInfo>();
        if (syncInfo != null) {
          children.put(container.getName(), syncInfo);
        }
        folderSyncCache.put(key, children);
      }

      return result;
    } catch (IOException e) {
      PerforceCorePlugin.logError(e.getMessage(), e);
      return null;
    }
  }

  /**
   * Wrapper for fstat().
   *
   * @param container that contains resources for which we need
   *        ResourceSyncInfo's.
   * @return array of ResourceSyncInfo.
   */
  @SuppressWarnings("unchecked")
  // resourceSyncCache.get
  public ResourceSyncInfo[] getResourceSyncInfo(IContainer container) {
    try {
      String key = createCacheKeyFromPath(container.getLocation());
      Map<Object, ResourceSyncInfo> children =
          (Map<Object, ResourceSyncInfo>) resourceSyncCache.get(key);
      if (children != null) {
        return children.values().toArray(new ResourceSyncInfo[children.size()]);
      }
    } catch (IOException e) {
      PerforceCorePlugin.logError("CacheStore.getResourceSyncInfo failed.", e); //$NON-NLS-1$
    }

    return new ResourceSyncInfo[0];
  }

  /**
   * Puts ResourceSyncInfos into the cache.
   *
   * @param resource the resource.
   * @param syncInfo the associated ResourceSyncInfo.
   * @return old sync info.
   */
  @SuppressWarnings("unchecked")
  // resourceSyncCache
  public ResourceSyncInfo setResourceSyncInfo(IResource resource, ResourceSyncInfo syncInfo) {
    ResourceSyncInfo result = null;
    try {
      IContainer parent = resource.getParent();
      String key = createCacheKeyFromPath(parent.getLocation());
      Map<String, ResourceSyncInfo> children =
          (Map<String, ResourceSyncInfo>) resourceSyncCache.get(key);
      if (children != null) {
        result = children.get(resource.getName());
        if (syncInfo == null) {
          children.remove(resource.getName());
          if (children.size() == 0) {
            resourceSyncCache.remove(key);
          } else {
            resourceSyncCache.put(key, children);
          }
        } else {
          if (!syncInfo.equals(result)) {
            children.put(resource.getName(), syncInfo);
            resourceSyncCache.put(key, children);
          }
        }
      } else {
        children = new HashMap<String, ResourceSyncInfo>();
        if (syncInfo != null) {
          children.put(resource.getName(), syncInfo);
        }
        resourceSyncCache.put(key, children);
      }

      return result;
    } catch (IOException e) {
      PerforceCorePlugin.logError("CacheStore.setResourceSyncInfo failed.", e); //$NON-NLS-1$
      return null;
    }
  }

  //----------------------------------------------------------------------------
  // - Private Methods

  /**
   * Creates the (resource sync) cache key from the given path.
   *
   * @param path the path.
   * @return the cache key.
   */
  private String createCacheKeyFromPath(IPath path) {
    if (EFS.getLocalFileSystem().isCaseSensitive()) {
      return path.toOSString();
    } else {
      return path.toOSString().toLowerCase();
    }
  }

  /**
   * Called to retrieve the PersistentMap with the given name.
   *
   * @param name the name of the map.
   * @return the PeristenMap.
   * @throws IOException in case of an unexpected I/O error.
   */
  private PersistentMap getPersistentMap(String name) throws IOException {
    long recordId = recordManager.getNamedObject(name);
    if (recordId == 0) {
      BTree tree = new BTree(recordManager, recordManagerCache, new StringComparator());
      recordId = tree.getRecid();
      recordManager.setNamedObject(name, recordId);
      recordManager.commit();
    }

    return new PersistentMap(recordManager, recordManagerCache, recordId);
  }

  // ----------------------------------------------------------------------
  // Private Nested Classes

  /** TODO: Convert HTree to BTree */
  private static class PersistentMap {
    // -----------------------------------------------------------------------
    // Private Variables

    /** Stores the RecordManager instance. */
    private RecordManager recordManager;
    /** Stores the BTree instance. */
    private BTree tree;
    /** Indicates whether the map is dirty or not. */
    private boolean isDirty;
    /** Caches access to the HTree. */
    private Map<Object, CacheMapEntry> cacheMap = new HashMap<Object, CacheMapEntry>();

    // ---------------------------------------------------------------------
    // Public Constructors

    /**
     * Constructor for PersistentMap.
     *
     * @param theRecordManager the RecordManager
     * @param cache the ObjectCache.
     * @param recordId the root id.
     * @throws IOException in case of an unexpected I/O error.
     */
    public PersistentMap(RecordManager theRecordManager, ObjectCache cache, long recordId)
        throws IOException {
      recordManager = theRecordManager;
      tree = BTree.load(theRecordManager, cache, recordId);
    }

    // -------------------------------------------------------------------------
    // Public Methods

    /**
     * Checks whether the persistent map is dirty or not.
     *
     * @return true or false.
     */
    public boolean isDirty() {
      return isDirty;
    }

    /**
     * Associates the specified value with the specified key.
     *
     * @param key key with which the specified value is to be associated.
     * @param value value to be associated with the specified key.
     * @throws IOException in case of an I/O Error.
     */
    public void put(Object key, Object value) throws IOException {
      cacheMap.put(key, new CacheMapEntry(value, true));
      isDirty = true;
    }

    /**
     * Returns the value which is associated with the given key. Returns
     * <code>null</code> if there is not association for this key.
     *
     * @param key key whose associated value is to be returned
     * @return the associated object.
     * @throws IOException in case of an I/O Error.
     */
    public Object get(Object key) throws IOException {
      Object result = cacheMap.get(key);
      if (result != null) {
        result = ((CacheMapEntry) result).getValue();
      } else {
        PersistentMapEntry entry = (PersistentMapEntry) tree.find(key);
        result = (entry != null) ? entry.get() : null;

        cacheMap.put(key, new CacheMapEntry(result, false));
      }

      return result;
    }

    /**
     * Remove the value which is associated with the given key. If the key does
     * not exist, this method simply ignores the operation.
     *
     * @param key key whose associated value is to be removed
     * @throws IOException in case of an I/O Error.
     */
    public void remove(Object key) throws IOException {
      cacheMap.remove(key);
      tree.remove(key);
      isDirty = true;
    }

    /**
     * Commit dirty data.
     *
     * @throws IOException in case of an I/O Error.
     */
    public void commit() throws IOException {
      if (isDirty) {
        for (Map.Entry<Object, CacheMapEntry> entry : cacheMap.entrySet()) {
          final CacheMapEntry value = entry.getValue();
          if (value.isDirty()) {
            tree.insert(entry.getKey(), new PersistentMapEntry(value.getValue()), true);
          }
        }

        cacheMap.clear();
        recordManager.commit();
        isDirty = false;
      }
    }

    /**
     * Disposes from this hashtable instance and frees associated resources.
     *
     * @throws IOException in case of an I/O Error.
     */
    public void dispose() throws IOException {
      commit();

      cacheMap = null;
      tree = null;
      recordManager = null;
    }
  }

  /**
   * This is a wrapper class around the objects stored in PersistentMap. Its
   * main purpose is to avoid massive deserialization costs for object that are
   * not accessed.
   */
  private static class PersistentMapEntry implements Externalizable {
    // -----------------------------------------------------------------------
    // Private Variables

    /** Holds the object. */
    private Object object;
    /** Stores the byte array representation of the object. */
    private byte[] byteArray;

    // ---------------------------------------------------------------------
    // Public Constructors

    /** Constructor for PersistentMapEntry. */
    public PersistentMapEntry() {
    }

    /**
     * Constructor for PersistentMapEntry.
     *
     * @param theObject the object to wrap.
     */
    public PersistentMapEntry(Object theObject) {
      object = theObject;
    }

    //--------------------------------------------------------------------------
    // Public Methods

    /**
     * Called to get the real object.
     *
     * @return the object.
     */
    public Object get() {
      try {
        if (object == null && byteArray != null) {
          final ByteArrayInputStream bin = new ByteArrayInputStream(byteArray);
          final ObjectInputStream in = new ObjectInputStream(bin);
          try {
            object = in.readObject();
          } finally {
            in.close();
            byteArray = null;
          }
        }

        return object;
      } catch (ClassNotFoundException e) {
        return null;
      } catch (StreamCorruptedException e) {
        return null;
      } catch (IOException e) {
        return null;
      }
    }

    public void readExternal(ObjectInput in) throws IOException {
      byteArray = new byte[in.readInt()];
      in.readFully(byteArray);
    }

    public void writeExternal(ObjectOutput out) throws IOException {
      if (byteArray == null) {
        final ByteArrayOutputStream bos = new ByteArrayOutputStream();
        final ObjectOutputStream os = new ObjectOutputStream(bos);
        os.writeObject(object);
        os.close();

        byteArray = bos.toByteArray();
        object = null;
      }

      out.writeInt(byteArray.length);
      out.write(byteArray);
    }
  }

  /**
   * This class is used to represent entry's in the cache map of PersistentMap
   * objects.
   */
  private static class CacheMapEntry {
    // -----------------------------------------------------------------------
    // Private Variables

    /** Holds the value object. */
    private final Object value;
    /** Indicates whether the entry is dirty. */
    private final boolean isDirty;

    // ---------------------------------------------------------------------
    // Public Constructors

    /**
     * Constructor for CacheMapEntry.
     *
     * @param theValue the value.
     */
    public CacheMapEntry(Object theValue, boolean dirty) {
      value = theValue;
      isDirty = dirty;
    }

    //--------------------------------------------------------------------------
    // Public Methods

    /**
     * Called to check whether the entry is dirty.
     *
     * @return true if entry is dirty, false otherwise.
     */
    public boolean isDirty() {
      return isDirty;
    }

    /**
     * Called to retrieve the entry's value.
     *
     * @return the value of the entry.
     */
    public Object getValue() {
      return value;
    }
  }
}
